查看原文
其他

船新版本之学习屏幕刷新机制引发的画面卡顿监控与优化的思考

LING 郭霖
2024-07-22


/   今日科技快讯   /

近日,普华永道将成为OpenAI企业产品的最大客户和首家经销商,这是两家公司5月29日宣布的新协议的一部分。普华永道表示,将向其7.5万名美国员工和2.6万名英国员工推出ChatGPT企业版。两家公司都拒绝透露交易的财务条款。普华永道去年曾宣布,计划在未来三年内向旗下美国业务的生成式AI技术投资10亿美元。

/   作者简介   /

本篇文章来自LING的投稿,文章主要分享了学习屏幕刷新机制引发的画面卡顿监控与优化中的思考,相信会对大家有所帮助!同时也感谢作者贡献的精彩文章。

LING的博客地址:
https://juejin.cn/user/263492889751640/posts

/   前言   /

想当标题党,但是发现标题怎么都理不顺,语文老师发现应该是会揍我,但是无所谓了,我感觉也算是很清楚了。咸鱼时间看了一篇屏幕刷新机制的好文,引发了对Choreographer的思考。

本文将采用四个标题论述。如下所示:

  • 第一章 屏幕的刷新机制
  • 第二章 System Trace和Java/Kotlin Method Trace的使用体验
  • 第三章 基于性能分析的简单优化理解
  • 第四章 未来的展望与惋惜

通过承上启下的方式贯穿全文,表达了作者的思乡之情......

/   屏幕的刷新机制   /

正如引言所说,小LING同学咸鱼时间(需求暂时干完bushi)看了一篇关于屏幕刷新机制的文章,然后就引发了一系列故事。如果对显示器有所了解的朋友大概会知道几个参数,分辨率,帧数,垂直同步,安卓设备也拥有可视化屏幕,自然会有类似的概念,搬一点gpt的回答。

屏幕刷新帧数:在安卓设备中,屏幕刷新帧数是指屏幕每秒钟能够更新的图像次数,屏幕刷新帧数通常用每秒帧数FPS来表示,例如60 FPS、90 FPS、120 FPS等。高刷新帧数通常带来更流畅的视觉体验,尤其在游戏、视频播放和图形处理应用中。

垂直同步:VSync是垂直同步的缩写,用于协调CPU和GPU的帧输出速度与显示器的刷新速率。启用VSync能够防止“屏幕撕裂”(tearing)现象,即显示器在刷新期间显示的图像由多个帧组成。

屏幕卡顿:屏幕卡顿(Screen Stuttering or Jitter)是指显示画面在播放动态内容时出现的不连贯、不流畅的现象。这种现象通常表现为图像更新停滞、跳跃或不规律,导致观众体验到明显的中断。

简单点说,帧数为60的手机屏幕一秒钟能够切换60张图片,如果呆在页面没动,系统可以根据策略选择跟着你一起呆,也可以使用垂直同步(AI?补帧?),切换60张相同的画面,但是如果你的手机很差(好像只要足够好,画面就不可能卡,卡就加机器!)/ 代码很差(代码不可能有问题,是Java的问题),一秒钟不足以生成60张图片,就会肉眼可见不连贯,画面撕裂,也就是卡顿了。

解释完一些概念,小LING同学开始感到困扰,既想把东西讲细一点,毕竟这是一个庞大的系列,又想把东西讲简单化,由深入浅,这样观看门槛不会很高,所以后面的文章风格可能会很杂交?当个乐子看吧。

一些屏幕刷新的思考

假如是60帧的手机,代表一秒切换60张图片,每张图片存在的周期是1s/60=1000ms/60=16.6ms,所以我们要在16.6ms计算完图片交给GPU和显示器去渲染上屏。

那我们什么时候开始干事情,什么时候开始渲染,我们又怎么去上屏???好在不用手动控制,系统会发送vsync信号通知我们进行屏幕刷新,我们只要在信号到来时保证cpu能够成功处理完图像交给底层就行,如下图:


小LING同学开始懵逼,这是啥。

  1. 既然是系统发送vsync信号,我更新UI的时候怎么知道vsync什么时候来?
  2. 画面不变的时候vsync来了我需要做什么吗,告诉它快点走,我不更新UI了?

我们可以反过来去理解,手动调用invalidate方法更新UI。

通过invalidate找到vsync信号

上才艺:

binding?.tvTest1?.setOnClickListener {
    it.invalidate()
}
// 简单解释一下,点击textview调用了自身的invalidate方法

偷懒上才艺:


思考一下怎么用这张图把这个事情说清楚,怎么说呢,小LING同学属于是看完源码自己爽了就不想动的那种人,石锤懒鬼,简单解释一下吧,读者可以自行去看源码。

最左边是我们点击事件触发自身invalidate函数的流程,走到了scheduleTraversals函数,中间是向底层注册vsync信号的流程,最右边是监听到vsync信号然后进行performTraversals函数的流程,听到这里懵逼了吗,我说了啥,我是不是啥也没说,毕竟还是技术科普类的文章,贴一点别人的源码解释:

我们知道一个 View 发起刷新的操作时,最终是走到了 ViewRootImpl 的 scheduleTraversals() 里去,然后这个方法会将遍历绘制 View 树的操作 performTraversals() 封装到 Runnable 里,传给 Choreographer,以当前的时间戳放进一个 mCallbackQueue 队列里,然后调用了 native 层的方法向底层注册监听下一个屏幕刷新信号事件。

当下一个屏幕刷新信号发出的时候,如果我们 app 有对这个事件进行监听,那么底层它就会回调我们 app 层的 onVsync() 方法来通知。当 onVsync() 被回调时,会发一个 Message 到主线程,将后续的工作切到主线程来执行。

切到主线程的工作就是去 mCallbackQueue 队列里根据时间戳将之前放进去的 Runnable 取出来执行,而这些 Runnable 有一个就是遍历绘制 View 树的操作 performTraversals()。在这次的遍历操作中,就会去绘制那些需要刷新的 View。

所以说,当我们调用了 invalidate(),requestLayout(),等之类刷新界面的操作时,并不是马上就会执行这些刷新的操作,而是通过 ViewRootImpl 的 scheduleTraversals() 先向底层注册监听下一个屏幕刷新信号事件,然后等下一个屏幕刷新信号来的时候,才会去通过 performTraversals() 遍历绘制 View 树来执行这些刷新操作。

屏幕刷新的总结

综上所述,小LING同学做个总结,对View进行invalidate函数调用的时候,会遍历寻找父亲节点,最后走到ViewRootImpl的scheduleTraversals函数:

void scheduleTraversals() {
    if (!mTraversalScheduled) {
        mTraversalScheduled = true;
        mTraversalBarrier = mHandler.getLooper().getQueue().postSyncBarrier();
        mChoreographer.postCallback(
            Choreographer.CALLBACK_TRAVERSAL, mTraversalRunnable, null);
        ......
    }
}
// mTraversalScheduled标识防止一帧内多次调用scheduleTraversals,上屏是个整体操作
// postSyncBarrier()同步屏障,优先执行异步消息
// mChoreographer.postCallback(Choreographer.CALLBACK_TRAVERSAL, mTraversalRunnable, null);发送CALLBACK_TRAVERSAL类型的消息进入队列并且对底层进行监听,待底层返回vsync刷新信号

这样对应了上图的最左边和中间的链路,感兴趣的同学可以去看源码,接下来是native操作,我们不用考虑什么时候底层什么时候告诉我们刷新页面,根据策略不同可能是16.6ms通知我们,也可能是没有UI操作的时候节约能耗不通知我们,待vsync信号上来的时候会执行刚才给出去的mTraversalRunnable,代码如下:

void doTraversal() {
    if (mTraversalScheduled) {
        mTraversalScheduled = false;
        mHandler.getLooper().getQueue().removeSyncBarrier(mTraversalBarrier);

        performTraversals();
    }
}
// vsync信号上来了,mTraversalScheduled标识重新置为false,为下一轮屏幕刷新服务
// removeSyncBarrier()移除同步屏障,执行同步消息
// performTraversals()屏幕刷新测量、布局、绘制三大流程

private void performTraversals() {
    ......
    performMeasure() 测量
    ......
    perfromLayout() 布局
    ......
    performDraw() 绘制
    ......
}
// 根据一些状态标识位判断是否需要执行测量、布局、绘制三大流程

这样对应了上图最右边的部分,至此,整个屏幕的刷新流程结束。

回过头解答一开始提出的问题。

既然是系统发送vsync信号,我更新UI的时候怎么知道vsync什么时候来?是的我们不需要知道,只要我们发起了刷新View的请求,就会往底层注册vsync信号,vsync信号上来的时候正常执行performTraversals函数即可。

画面不变的时候vsync来了我需要做什么吗,告诉它快点走,我不更新UI了?理论上vsync信号不来的时候,我们确实不需要做什么,因为我们本来就没有更新UI操作,帧数维持1即可,但是1到60可能存在一个帧率启动问题?画面即使不变化,垂直同步还是会拿60帧画面,这里看是通过每16.6ms发vsync信号还是底层自己取上一帧的画面循环了。

第一帧,没人主动调invalidate,画面哪来的?涉及到Activity,attach(),onCreate()和onReume()的一些骚操作,大意就是attach()创建phoneWindow,wm,onCreate()生成decorView并且添加,onReume()调用wm.addView初始化ViewRootImpl,主动触发requestLayout(),如下:

public void requestLayout() {
    if (!mHandlingLayoutInLayoutRequest) {
        checkThread();
        mLayoutRequested = true;
        scheduleTraversals();
    }
}
// checkThread()涉及到了一些onCreate函数时机更新UI,和子线程使用wm更新UI的骚操作
// mLayoutRequested更新布局标签
// scheduleTraversals()因此最后还是走到了上面分析的流程

/   System Trace 和Java/Kotlin Method Trace使用体验   /

打开Profiler之后,小LING同学从 “富婆,饿,饭饭” 到 ”大佬,救救,看不懂“ 的心理历程转变,当然也可能是反过来的。

“看不懂,这是啥,我在干嘛,这是什么东西,怎么这么卡,我该怎么办” ,打开Profiler的真实心声,于是我去互联网冲浪,翱翔,漫游,弄了套自己的浅显理解。

话不多说,来个例子:

private fun anima(view: TextView?) {
    if (view == null) {
        return
    }
    // 循环播放放大缩小动画
    val scaleUpX = ObjectAnimator.ofFloat(view, "scaleX", 1.0f, 1.5f)
    val scaleUpY = ObjectAnimator.ofFloat(view, "scaleY", 1.0f, 1.5f)
    val scaleDownX = ObjectAnimator.ofFloat(view, "scaleX", 1.5f, 1.0f)
    val scaleDownY = ObjectAnimator.ofFloat(view, "scaleY", 1.5f, 1.0f)

    val scaleUp = AnimatorSet()
    scaleUp.playTogether(scaleUpX, scaleUpY)
    scaleUp.setDuration(500)

    val scaleDown = AnimatorSet()
    scaleDown.playTogether(scaleDownX, scaleDownY)
    scaleDown.setDuration(500)

    val animatorSet = AnimatorSet()
    animatorSet.playSequentially(scaleUp, scaleDown)

    animatorSet.addListener(object : Animator.AnimatorListener {
        override fun onAnimationStart(animation: Animator) {}
        override fun onAnimationEnd(animation: Animator) {
            animation.start()
        }

        override fun onAnimationCancel(animation: Animator) {}
        override fun onAnimationRepeat(animation: Animator) {}
    })

    animatorSet.start()
}

private fun test() {
    anima(binding?.tvTest1)
    binding?.tvTest1?.setOnClickListener {
        test1()
    }
}

private fun test1() {
    Thread.sleep(1000)
}
// anima()循环播放动画
// 点击view就睡眠1s,也可以模拟成主线程的耗时操作

打开System Trace查看点击一次的变化


  1. 选择需要分析的应用程序,点击CPU准备Record,
  2. All Frames代表所有帧,JankyFrames代表丢掉的帧,
  3. 可以很明显看出来,点击后出现了一个卡顿帧,页面沉睡了1000ms,正好卡帧了1s。

打开Java/Kotlin Method Trace查看点击一次的变化


小tips:系统 API 的调用显示为橙色,应用自写方法的调用显示为绿色,第三方 API(包括 Java 语言 API)的调用显示为蓝色。

  1. 可以看到橙色是系统API,绿色是自己写的函数test1,也就是后面我们能优化的函数,蓝色是三方API
  2. Flame Chart?帧记录表?Flame Chart提供了调用栈的反向调用图,Flame Chart中的水平条表示出现在相同的调用序列中同一方法的执行时间,从图中我们很容易发现哪个方法消耗的时间最多
  3. 可以看到onClick这条是比较长的,几乎占了一半,鼠标移动到上面能够发现也是1s

专门开个小标题吐个槽

是我小LING同学使用姿势不对吗,这两个功能也太割裂了,我通过System Trace拿到了卡顿帧和日志,但是里面可用的情报少得可怜,我他么怎么知道是哪些函数堵住了,于是乎这时候我又打开Java/Kotlin Method Trace模拟刚才的操作,方法耗时倒是有了,由于打开Trace会让程序变慢,实际项目中log只用了10ms的方法它跑了500ms!

System Trace完美模拟丢帧需要尽可能不干扰代码执行,而Java/Kotlin Method Trace拿到耗时又需要尽可能干扰函数,但是这样太割裂,没有折中的方法吗,比如System Trace塞几个可用信息的方法链路进去,或者Java/Kotlin Method Trace跟着帧模拟?应该是有的,所以说 ”大佬,救救,看不懂“ 。

/   基于性能分析的简单优化理解   /

简单的用Profiler进行了观测,但是我们不可能只观测不解决吧,为什么会卡顿呢,其实一开始有说到两个原因:

  1. 手机很差(好像只要足够好,画面就不可能卡,卡就加机器!)
  2. 代码很差(代码不可能有问题,是Java的问题)

显然我们的科技和经济实力是不允许一直加机器的,更何况还有一招Thread.sleep(365天),一年触发一次,代码差也不太可能,世界上没有人不会写代码,只可能是idea的问题,更或者是编程语言的问题。

这下已经陷入了死局,小LING同学绞尽脑汁,咬文嚼字,博览群书,总算发现了一些小原因:

主线程耗时太久,留给画面刷新的时间不够。换一下test1的代码:

private fun test1() {
    Thread {
        Thread.sleep(1000)
    }.start()
}
// 将耗时操作交给线程去处理,拿到操作的结果后再交给主线程使用

这代码有点抽象但是又没那么抽象,把Thread.sleep(1000)看作主线程的耗时操作,比如看作Json数据转换,文件读取,一个操作就是大几十ms,在你没注意的情况,你就是有可能放在了主线程。

例如本地存了userInfo的Json,每次获取userId,userPhone都写了一个方法去转换然后取值(一次10ms),再当作网络接口的参数:

MainHttp.getData(UserUtils.getUserId(), UserUtils.getUserPhone()).subscribe()
// 恭喜,这个方法至少堵住了主线程10ms+10ms的时间,如果一个页面100个请求呢?
// 我们还在疑惑为什么每次跳转这个页面都会卡一下,我明明用了rxjava啊,都是异步请求了,难道是手机不行?
// 嘻嘻嘻嘻嘻嘻

具体怎么改造小LING同学也是懵懂状态,手动rxjava发送emitter确实有点蠢,我想到的完美的解决方法是协程:

val userId = 协程版UserUtils.getUserId()
val userPhone = 协程版UserUtils.getUserPhone()
MainHttp.getData(userId, userPhone).subscribe() // 我都协程了怎么还是rxjava请求网络,不会这个人不会写吧(bushi
// 顶多1ms+1ms

总结,主线程执行了耗时任务,这需要时间,自然没有时间去刷新UI,可以通过Java/Kotlin Method Trace查看main线程是不是有这些绿色的方法存在,少年去干掉他们吧!

不合理、过量的页面刷新、层级嵌套、UI绘制

来个test2的代码:

private fun test2() {
    for (index in 0..1000) {
        val tempTextView = TextView(this)
        tempTextView.text = "我在放大缩小哦"
        tempTextView.layoutParams = LinearLayout.LayoutParams(
            LinearLayout.LayoutParams.WRAP_CONTENT,
            LinearLayout.LayoutParams.WRAP_CONTENT
        )
        binding?.llBg?.addView(tempTextView)
        anima(tempTextView)
    }
    // 1000个你说肉眼不卡?好好好?那这样呢
    // tempTextView.postDelayed({ test2() }, 100)
}

其实代码有点夸张了,但是不可否认,不合理、过量的页面刷新、层级嵌套、UI绘制,也会造成页面卡顿,我们简单看看Profiler分析结构:



可以看到卡顿帧频繁出现,也可以看到绿色的动画调用函数频繁出现,一个函数在观测模式下只有5ms,但是100个,1000个!

总结,虽然主线程没有什么耗时任务,全心全意的在跑UI绘制的代码,但是不合理、过量的UI绘制过多,始终不能很好的顾及每个View,导致了结局必定出现的卡顿。

/   未来的展望与惋惜   /

洋洋洒洒4000字,文章总算来到了尾声。

小LING同学一开始只是看了一篇屏幕刷新机制的文章,后面升起了玩一下Profiler的想法,再后面开始感叹这个过程还能继续不停的延伸。

先说未来

只关注Profiler的局限性还是很大:

  1. 开启方法Trace后,函数运行效率大大降低,能作为参考和排查问题的思路,得不到比较真实的优化数据。该问题的解决方法应该是采用插桩之类的方案,对方法进行计时,发起技术埋点,进行上报,进行标准的制定,根据标准控制卡顿代码。
  2. debug环境下的优化始终算事前,并做不到线上的监控分析,该问题的解决方法应该是采用类似于watchdog方案监听线上的函数执行情况。

这算是小LING同学的弱项,深度拉得比较高了,有点跟不上,未来有机会可以对这个进行研究,自己玩玩。

再说惋惜

光阴似箭,岁月如梭,谁能想到预料到未来的自己还有多少热情、多少时间去写这样的文章。

引言上写自己亲身经历恋爱小故事的作者,已经两三年没有更新文章了,现在的他是否已经释怀了,是否已经结婚了,是否还会打开简书去看自己的私信呢?

或许过好当下,珍惜身边人才是需要考虑的问题。

最后碍于规模等问题,实际应用存在方案存在很大的局限性和未验证性,欢迎大佬的美好意见。

推荐阅读:
我的新书,《第一行代码 第3版》已出版!
用华为鸿蒙手写ECharts
原创:写给初学者的Jetpack Compose教程,用derivedStateOf提升性能

欢迎关注我的公众号
学习技术或投稿


长按上图,识别图中二维码即可关注
继续滑动看下一个
向上滑动看下一个

您可能也对以下帖子感兴趣

文章有问题?点此查看未经处理的缓存